现在,我们终于进入到了逻辑层的开发,之前我们已经准备好了相关的数据并且已经让组件连接,这里会省不少事情。但是整个交互的逻辑还是比较复杂的,希望大家能够提前做好心理准备,迎接这个挑战吧。
首先把问题拆分一下,对播放器而言,进行交互的部分无非就是两个部分:mini版和全屏版。我们先从简单一些的mini版开始入手吧。
# mini播放器
mini播放器目前依赖的数据是播放状态和播放进度数据。
const { song, fullScreen, playing, percent } = props;
const { clickPlaying, setFullScreen } = props;
进度条这里的JSX代码也需要修改一下:
// 暂停的时候唱片也停止旋转
<img className={`play ${playing ? "": "pause"}`} src={song.al.picUrl} width="40" height="40" alt="img"/>
<ProgressCircle radius={32} percent={percent}>
{ playing ?
<i className="icon-mini iconfont icon-pause" onClick={e => clickPlaying(e, false)}></i>
:
<i className="icon-mini iconfont icon-play" onClick={e => clickPlaying(e, true)}></i>
}
</ProgressCircle>
当然在父组件中也要做相应修改:
const clickPlaying = (e, state) => {
e.stopPropagation();
togglePlayingDispatch(state);
};
return (
<div>
<MiniPlayer
song={currentSong}
fullScreen={fullScreen}
playing={playing}
toggleFullScreen={toggleFullScreenDispatch}
clickPlaying={clickPlaying}
/>
<NormalPlayer
song={currentSong}
fullScreen={fullScreen}
playing={playing}
toggleFullScreen={toggleFullScreenDispatch}
clickPlaying={clickPlaying}
/>
</div>
)
# 初次完成播放
Ok, 现在我们来处理更复杂的全屏播放器部分。
首先定义必要的播放器属性:
//Player/index.js
//目前播放时间
const [currentTime, setCurrentTime] = useState(0);
//歌曲总时长
const [duration, setDuration] = useState(0);
//歌曲播放进度
let percent = isNaN(currentTime / duration) ? 0 : currentTime / duration;
同时需要接受redux中的currentIndex:
const { fullScreen, playing, currentIndex, currentSong: immutableCurrentSong } = props;
const { toggleFullScreenDispatch, togglePlayingDispatch, changeCurrentIndexDispatch, changeCurrentDispatch } = props;
let currentSong = immutableCurrentSong.toJS();
我们现在的当务之急是让播放器能够播放, 所以现在我们需要放上我们的核心元素————audio标签:
//绑定ref
const audioRef = useRef();
return (
<div>
//...
<audio ref={audioRef}></audio>
</div>
)
现在先写一些逻辑:
//mock一份playList,后面直接从 redux 拿,现在只是为了调试播放效果。
const playList = [
{
ftype: 0,
djId: 0,
a: null,
cd: '01',
crbt: null,
no: 1,
st: 0,
rt: '',
cf: '',
alia: [
'手游《梦幻花园》苏州园林版推广曲'
],
rtUrls: [],
fee: 0,
s_id: 0,
copyright: 0,
h: {
br: 320000,
fid: 0,
size: 9400365,
vd: -45814
},
mv: 0,
al: {
id: 84991301,
name: '拾梦纪',
picUrl: 'http://p1.music.126.net/M19SOoRMkcHmJvmGflXjXQ==/109951164627180052.jpg',
tns: [],
pic_str: '109951164627180052',
pic: 109951164627180050
},
name: '拾梦纪',
l: {
br: 128000,
fid: 0,
size: 3760173,
vd: -41672
},
rtype: 0,
m: {
br: 192000,
fid: 0,
size: 5640237,
vd: -43277
},
cp: 1416668,
mark: 0,
rtUrl: null,
mst: 9,
dt: 234947,
ar: [
{
id: 12084589,
name: '妖扬',
tns: [],
alias: []
},
{
id: 12578371,
name: '金天',
tns: [],
alias: []
}
],
pop: 5,
pst: 0,
t: 0,
v: 3,
id: 1416767593,
publishTime: 0,
rurl: null
}
];
useEffect(() => {
if(!currentSong) return;
changeCurrentIndexDispatch(0);//currentIndex默认为-1,临时改成0
let current = playList[0];
changeCurrentDispatch(current);//赋值currentSong
audioRef.current.src = getSongUrl(current.id);
setTimeout(() => {
audioRef.current.play();
});
togglePlayingDispatch(true);//播放状态
setCurrentTime(0);//从头开始播放
setDuration((current.dt / 1000) | 0);//时长
}, []);
其中,getSongUrl为一个封装在api/utils.js中的方法:
//拼接出歌曲的url链接
export const getSongUrl = id => {
return `https://music.163.com/song/media/outer/url?id=${id}.mp3`;
};
引入:
import { getSongUrl } from "../../api/utils";
但是你现在会看到这样的报错信息:

这是因为初始化store数据的时候,currentSong是一个空对象,song.al为undefined, 因此song.al.picUrl就会报错。
那怎么来规避这个问题呢?
很简单,我们在渲染播放器的时候判断一下currentSong是否对空对象就可以了。
import { isEmptyObject } from "../../api/utils";
//JSX
return (
<div>
{ isEmptyObject(currentSong) ? null :
<MiniPlayer
song={currentSong}
fullScreen={fullScreen}
playing={playing}
toggleFullScreen={toggleFullScreenDispatch}
clickPlaying={clickPlaying}
/>
}
{ isEmptyObject(currentSong) ? null :
<NormalPlayer
song={currentSong}
fullScreen={fullScreen}
playing={playing}
toggleFullScreen={toggleFullScreenDispatch}
clickPlaying={clickPlaying}
/>
}
<audio ref={audioRef}></audio>
</div>
)
好,现在你打开项目应该可以听到背景音乐了,现在我们迈出了第一步。接下来就是一步步不断地完善播放的逻辑。
# 播放和暂停
首先是播放和暂停的逻辑。
其实之前已经完成,只不过没有和audio元素对接。现在通过监听playing变量来实现:
useEffect(() => {
playing ? audioRef.current.play() : audioRef.current.pause();
}, [playing]);
现在在mini播放器可以看到效果,但是normalPlayer里面却没反应,现在补充上里面的逻辑。
//normalPlayer/index.js
const { song, fullScreen, playing } = props;
const { toggleFullScreen, clickPlaying } = props;
//JSX中的修改
//CdWrapper下唱片图片
<div className="cd">
<img
className={`image play ${playing ? "" : "pause"}`}
src={song.al.picUrl + "?param=400x400"}
alt=""
/>
</div>
//中间暂停按钮
<div className="icon i-center">
<i
className="iconfont"
onClick={e => clickPlaying(e, !playing)}
dangerouslySetInnerHTML={{
__html: playing ? "" : ""
}}
></i>
</div>
# 进度控制
之前写的播放时间都是mock数据, 现在填充成动态数据。
//父组件传值
<NormalPlayer
song={currentSong}
fullScreen={fullScreen}
playing={playing}
duration={duration}//总时长
currentTime={currentTime}//播放时间
percent={percent}//进度
toggleFullScreen={toggleFullScreenDispatch}
clickPlaying={clickPlaying}
/>
同时有一点需要注意,就是audio标签在播放的过程中会不断地触发onTimeUpdate事件,在此需要更新currentTime变量。
const updateTime = e => {
setCurrentTime(e.target.currentTime);
};
//JSX
<audio
ref={audioRef}
onTimeUpdate={updateTime}
></audio>
现在在normalPlayer中:
const { song, fullScreen, playing, percent, duration, currentTime } = props;
const { toggleFullScreen, clickPlaying, onProgressChange } = props;
//相应属性传给进度条
<ProgressWrapper>
<span className="time time-l">{formatPlayTime(currentTime)}</span>
<div className="progress-bar-wrapper">
<ProgressBar
percent={percent}
percentChange={onProgressChange}
></ProgressBar>
</div>
<div className="time time-r">{formatPlayTime(duration)}</div>
</ProgressWrapper>
ps: 其中,formatPlayTime为api/utils.js中的一个工具函数:
//转换歌曲播放时间
export const formatPlayTime = interval => {
interval = interval | 0;// |0表示向下取整
const minute = (interval / 60) | 0;
const second = (interval % 60).toString().padStart(2, "0");
return `${minute}:${second}`;
};
我要强调的重点是传给ProgressBar的两个参数,一个是percent,用来控制进度条的显示长度,另一个是onProgressChange,这个其实是一个进度条被滑动或点击时用来改变percent的回调函数。我们在父组件来定义它:
const onProgressChange = curPercent => {
const newTime = curPercent * duration;
setCurrentTime(newTime);
audioRef.current.currentTime = newTime;
if (!playing) {
togglePlayingDispatch(true);
}
};
//父组件传值
<NormalPlayer
//...
onProgressChange={onProgressChange}
/>
那么之前封装的进度条组件并没有处理percent相关的逻辑,现在在进度条组件中来增加。
const transform = prefixStyle('transform');
const { percent } = props;
const { percentChange } = props;
//监听percent
useEffect(() => {
if(percent >= 0 && percent <= 1 && !touch.initiated) {
const barWidth = progressBar.current.clientWidth - progressBtnWidth;
const offsetWidth = percent * barWidth;
progress.current.style.width = `${offsetWidth}px`;
progressBtn.current.style[transform] = `translate3d(${offsetWidth}px, 0, 0)`;
}
// eslint-disable-next-line
}, [percent]);
const _changePercent = () => {
const barWidth = progressBar.current.clientWidth - progressBtnWidth;
const curPercent = progress.current.clientWidth / barWidth;
percentChange(curPercent);
}
//点击和滑动结束事件改变percent
const progressClick = (e) => {
//...
_changePercent();
}
const progressTouchEnd = (e) => {
//...
_changePercent();
}
OK, 进度条被我们改了差不多了,现在就能够对接我们的播放器进度啦!

在最后,我们也把mini播放器的进度对接一下:
//父组件传值
<MiniPlayer
//...
percent={percent}
></MiniPlayer>
//miniPlayer/index.js
const { full, song, playing, percent } = props;
//JSX
<ProgressCircle radius={32} percent={percent}>
//...
做到这里大家可以完完整整地听一首歌了,实在不容易,接下来还有上一曲和下一曲的功能,我们慢慢来。
# 上下曲切换逻辑
//一首歌循环
const handleLoop = () => {
audioRef.current.currentTime = 0;
changePlayingState(true);
audioRef.current.play();
};
const handlePrev = () => {
//播放列表只有一首歌时单曲循环
if (playList.length === 1) {
handleLoop();
return;
}
let index = currentIndex - 1;
if (index < 0) index = playList.length - 1;
if (!playing) togglePlayingDispatch(true);
changeCurrentIndexDispatch(index);
};
const handleNext = () => {
//播放列表只有一首歌时单曲循环
if (playList.length === 1) {
handleLoop();
return;
}
let index = currentIndex + 1;
if (index === playList.length) index = 0;
if (!playing) togglePlayingDispatch(true);
changeCurrentIndexDispatch(index);
};
这部分逻辑传给normalPlayer:
//传递给normalPlayer
handlePrev={handlePrev}
handleNext={handleNext}
在normalPlayer中绑定按钮点击事件:
const { toggleFullScreen, clickPlaying, onProgressChange, handlePrev, handleNext } = props;
//JSX
<div className="icon i-left" onClick={handlePrev}>
<i className="iconfont"></i>
</div>
//...
<div className="icon i-right" onClick={handleNext}>
<i className="iconfont"></i>
</div>
现在我们把父组件中控制歌曲播放的的逻辑完善一下:
//记录当前的歌曲,以便于下次重渲染时比对是否是一首歌
const [preSong, setPreSong] = useState({});
//先mock一份currentIndex
useEffect(() => {
changeCurrentIndexDispatch(0);
}, [])
useEffect(() => {
if (
!playList.length ||
currentIndex === -1 ||
!playList[currentIndex] ||
playList[currentIndex].id === preSong.id
)
return;
let current = playList[currentIndex];
changeCurrentDispatch(current);//赋值currentSong
setPreSong(current);
audioRef.current.src = getSongUrl(current.id);
setTimeout(() => {
audioRef.current.play();
});
togglePlayingDispatch(true);//播放状态
setCurrentTime(0);//从头开始播放
setDuration((current.dt / 1000) | 0);//时长
}, [playList, currentIndex]);
# 播放模式
分三种: 单曲循环、顺序循环和随机播放
我们先在Player/index.js,也就是父组件中写相应逻辑:
//从props中取redux数据和dispatch方法
const {
playing,
currentSong:immutableCurrentSong,
currentIndex,
playList:immutablePlayList,
mode,//播放模式
sequencePlayList:immutableSequencePlayList,//顺序列表
fullScreen
} = props;
const {
togglePlayingDispatch,
changeCurrentIndexDispatch,
changeCurrentDispatch,
changePlayListDispatch,//改变playList
changeModeDispatch,//改变mode
toggleFullScreenDispatch
} = props;
const playList = immutablePlayList.toJS();
const sequencePlayList = immutableSequencePlayList.toJS();
const currentSong = immutableCurrentSong.toJS();
现在的需求是点击normalPlayer最左边的按钮,能够切换播放模式,我们现在在父组件写相应的逻辑。
顺便说一句。不知道你发现没有: 关于业务逻辑的部分都是在父组件完成然后直接传给子组件,而子组件虽然也有自己的状态,但大部分是控制UI层面的,逻辑都是从props中接受, 这也是在潜移默化中给大家展示了UI和逻辑分离的组件设计模式。通过分离关注点,解耦不同的模块,能够大量节省开发和维护成本。
//Player/index
const changeMode = () => {
let newMode = (mode + 1) % 3;
if (newMode === 0) {
//顺序模式
changePlayListDispatch(sequencePlayList);
let index = findIndex(currentSong, sequencePlayList);
changeCurrentIndexDispatch(index);
} else if (newMode === 1) {
//单曲循环
changePlayListDispatch(sequencePlayList);
} else if (newMode === 2) {
//随机播放
let newList = shuffle(sequencePlayList);
let index = findIndex(currentSong, newList);
changePlayListDispatch(newList);
changeCurrentIndexDispatch(index);
}
changeModeDispatch(newMode);
};
目前的播放列表是在组件内mock的,现在已经不太合适,我们把mock列表移动到reducer中的defaultState中,这里就不展示了,要注意playList和sequenceList都要mock并且mock一样的数据。
接下来我们来解释一下changeMode中的内容,findIndex方法用来找出歌曲在对应列表中的索引,shuffle方法用来打乱一个列表,达成随机列表的效果,这两个函数都定义在api/utils.js中。
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
// 随机算法
export function shuffle(arr) {
let new_arr = [];
arr.forEach(item => {
new_arr.push(item);
});
for (let i = 0; i < new_arr.length; i++) {
let j = getRandomInt(0, i);
let t = new_arr[i];
new_arr[i] = new_arr[j];
new_arr[j] = t;
}
return new_arr;
}
// 找到当前的歌曲索引
export const findIndex = (song, list) => {
return list.findIndex(item => {
return song.id === item.id;
});
};
引入到父组件:
import { getSongUrl, isEmptyObject, shuffle, findIndex } from "../../api/utils";
接下来我们给normalPlayer传入:
<NormalPlayer
//...
mode={mode}
changeMode={changeMode}
/>
现在就需要对normalPlayer做一些事情了:
//Operator标签下
<div className="icon i-left" onClick={changeMode}>
<i
className="iconfont"
dangerouslySetInnerHTML={{ __html: getPlayMode() }}
></i>
</div>
//getPlayMode方法
const getPlayMode = () => {
let content;
if (mode === playMode.sequence) {
content = "";
} else if (mode === playMode.loop) {
content = "";
} else {
content = "";
}
return content;
};
其中playMode常量我们已经定义过,直接引入:
import { playMode } from '../../../api/config';
现在大家打开redux-devtools可以看到数据的变化,下面是随机模式

可以看到playList现在已经乱序了。
功能是实现了,但是只有一个图标放在这里,可能很多用户不知道是什么意思,如果能够文字提示一下,体验会更好一些。废话不多说,直接开始封装崭新的Toast组件,这里只是由于是侧重项目, 不可能将Toast的功能面面俱到,只是让大家体会一下封装的过程,以此来提升自己的内功,这也是我不用UI框架的原因。
在baseUI目录下新建Toast文件夹:
//Toast/index.js
import React, {useState, useImperativeHandle, forwardRef} from 'react';
import styled from 'styled-components';
import { CSSTransition } from 'react-transition-group';
import style from '../../assets/global-style';
const ToastWrapper = styled.div`
position: fixed;
bottom: 0;
z-index: 1000;
width: 100%;
height: 50px;
/* background: ${style["highlight-background-color"]}; */
&.drop-enter{
opacity: 0;
transform: translate3d(0, 100%, 0);
}
&.drop-enter-active{
opacity: 1;
transition: all 0.3s;
transform: translate3d(0, 0, 0);
}
&.drop-exit-active{
opacity: 0;
transition: all 0.3s;
transform: translate3d(0, 100%, 0);
}
.text{
line-height: 50px;
text-align: center;
color: #fff;
font-size: ${style["font-size-l"]};
}
`
//外面组件需要拿到这个函数组件的ref,因此用forwardRef
const Toast = forwardRef((props, ref) => {
const [show, setShow] = useState(false);
const [timer, setTimer] = useState('');
const {text} = props;
//外面组件拿函数组件ref的方法,用useImperativeHandle这个hook
useImperativeHandle(ref, () => ({
show() {
// 做了防抖处理
if(timer) clearTimeout(timer);
setShow(true);
setTimer(setTimeout(() => {
setShow(false)
}, 3000));
}
}))
return (
<CSSTransition in={show} timeout={300} classNames="drop" unmountOnExit>
<ToastWrapper>
<div className="text">{text}</div>
</ToastWrapper>
</CSSTransition>
)
});
export default React.memo(Toast);
现在放到Player/index.js中使用:
import Toast from "./../../baseUI/toast/index";
//...
const [modeText, setModeText] = useState("");
const toastRef = useRef();
//...
const changeMode = () => {
let newMode = (mode + 1) % 3;
if (newMode === 0) {
//...
setModeText("顺序循环");
} else if (newMode === 1) {
//...
setModeText("单曲循环");
} else if (newMode === 2) {
//...
setModeText("随机播放");
}
changeModeDispatch(newMode);
toastRef.current.show();
};
//JSX
return (
<div>
//...
<Toast text={modeText} ref={toastRef}></Toast>
</div>
)
效果:

那现在还有最后一个问题需要处理,就是歌曲播放完了之后,紧接着需要怎么处理。
我们回到父组件,把这个处理逻辑写在audio标签的onEnded事件回调中:
<audio
ref={audioRef}
onTimeUpdate={updateTime}
onEnded={handleEnd}
></audio>
由于之前封装了下一曲和单曲循环的逻辑,这里就非常简单了。
import { playMode } from '../../api/config';
//...
const handleEnd = () => {
if (mode === playMode.loop) {
handleLoop();
} else {
handleNext();
}
};
OK,到这里,mini/全屏播放器基本的功能都完成了!